@@ -0,0 +1,21 @@ |
||
| 1 |
+class ScenarioImportsController < ApplicationController |
|
| 2 |
+ def new |
|
| 3 |
+ @scenario_import = ScenarioImport.new |
|
| 4 |
+ end |
|
| 5 |
+ |
|
| 6 |
+ def create |
|
| 7 |
+ @scenario_import = ScenarioImport.new(params[:scenario_import]) |
|
| 8 |
+ @scenario_import.set_user(current_user) |
|
| 9 |
+ |
|
| 10 |
+ if @scenario_import.valid? |
|
| 11 |
+ if @scenario_import.do_import? |
|
| 12 |
+ @scenario_import.import! |
|
| 13 |
+ redirect_to @scenario_import.scenario, notice: "Import successful!" |
|
| 14 |
+ else |
|
| 15 |
+ render action: "new" |
|
| 16 |
+ end |
|
| 17 |
+ else |
|
| 18 |
+ render action: "new" |
|
| 19 |
+ end |
|
| 20 |
+ end |
|
| 21 |
+end |
@@ -0,0 +1,80 @@ |
||
| 1 |
+# This is a helper class for managing Scenario imports. |
|
| 2 |
+class ScenarioImport |
|
| 3 |
+ include ActiveModel::Model |
|
| 4 |
+ include ActiveModel::Callbacks |
|
| 5 |
+ include ActiveModel::Validations::Callbacks |
|
| 6 |
+ |
|
| 7 |
+ URL_REGEX = /\Ahttps?:\/\//i |
|
| 8 |
+ |
|
| 9 |
+ attr_accessor :file, :url, :data, :do_import |
|
| 10 |
+ |
|
| 11 |
+ attr_reader :user |
|
| 12 |
+ |
|
| 13 |
+ before_validation :fetch_url |
|
| 14 |
+ before_validation :parse_file |
|
| 15 |
+ |
|
| 16 |
+ validate :validate_presence_of_file_url_or_data |
|
| 17 |
+ validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid" |
|
| 18 |
+ validate :validate_data |
|
| 19 |
+ |
|
| 20 |
+ def step_one? |
|
| 21 |
+ data.blank? |
|
| 22 |
+ end |
|
| 23 |
+ |
|
| 24 |
+ def step_two? |
|
| 25 |
+ valid? |
|
| 26 |
+ end |
|
| 27 |
+ |
|
| 28 |
+ def set_user(user) |
|
| 29 |
+ @user = user |
|
| 30 |
+ end |
|
| 31 |
+ |
|
| 32 |
+ def existing_scenario |
|
| 33 |
+ @existing_scenario ||= user.scenarios.find_by_guid(parsed_data["guid"]) |
|
| 34 |
+ end |
|
| 35 |
+ |
|
| 36 |
+ def parsed_data |
|
| 37 |
+ @parsed_data |
|
| 38 |
+ end |
|
| 39 |
+ |
|
| 40 |
+ def do_import? |
|
| 41 |
+ do_import == "1" |
|
| 42 |
+ end |
|
| 43 |
+ |
|
| 44 |
+ def import! |
|
| 45 |
+ end |
|
| 46 |
+ |
|
| 47 |
+ def scenario |
|
| 48 |
+ existing_scenario |
|
| 49 |
+ end |
|
| 50 |
+ |
|
| 51 |
+ protected |
|
| 52 |
+ |
|
| 53 |
+ def parse_file |
|
| 54 |
+ if data.blank? && file.present? |
|
| 55 |
+ self.data = file.read |
|
| 56 |
+ end |
|
| 57 |
+ end |
|
| 58 |
+ |
|
| 59 |
+ def fetch_url |
|
| 60 |
+ if data.blank? && url.present? && url =~ URL_REGEX |
|
| 61 |
+ self.data = Faraday.get(url).body |
|
| 62 |
+ end |
|
| 63 |
+ end |
|
| 64 |
+ |
|
| 65 |
+ def validate_data |
|
| 66 |
+ if data.present? |
|
| 67 |
+ @parsed_data = JSON.parse(data) rescue {}
|
|
| 68 |
+ if (%w[name guid] - @parsed_data.keys).length > 0 |
|
| 69 |
+ errors.add(:base, "The provided data does not appear to be a valid Scenario.") |
|
| 70 |
+ self.data = nil |
|
| 71 |
+ end |
|
| 72 |
+ end |
|
| 73 |
+ end |
|
| 74 |
+ |
|
| 75 |
+ def validate_presence_of_file_url_or_data |
|
| 76 |
+ unless file.present? || url.present? || data.present? |
|
| 77 |
+ errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.") |
|
| 78 |
+ end |
|
| 79 |
+ end |
|
| 80 |
+end |
@@ -0,0 +1,23 @@ |
||
| 1 |
+<div class="page-header"> |
|
| 2 |
+ <h2> |
|
| 3 |
+ Import a Public Scenario |
|
| 4 |
+ </h2> |
|
| 5 |
+</div> |
|
| 6 |
+ |
|
| 7 |
+<blockquote>You can import Scenarios, either from a <code>.json</code> file, or via a public Scenario URL. When you import a Scenario, Huginn will keep track of where it came from and later let you update it.</blockquote> |
|
| 8 |
+ |
|
| 9 |
+<div class="col-md-4"> |
|
| 10 |
+ <div class="form-group"> |
|
| 11 |
+ <%= f.label :url, 'Option 1: Provide a Public Scenario URL' %> |
|
| 12 |
+ <%= f.text_field :url, :class => 'form-control', :placeholder => "Public Scenario URL" %> |
|
| 13 |
+ </div> |
|
| 14 |
+ |
|
| 15 |
+ <div class="form-group"> |
|
| 16 |
+ <%= f.label :file, 'Option 2: Upload a Scenario JSON File' %> |
|
| 17 |
+ <%= f.file_field :file, :class => 'form-control' %> |
|
| 18 |
+ </div> |
|
| 19 |
+ |
|
| 20 |
+ <div class='form-actions'> |
|
| 21 |
+ <%= f.submit "Start Import", :class => "btn btn-primary" %> |
|
| 22 |
+ </div> |
|
| 23 |
+</div> |
@@ -0,0 +1,51 @@ |
||
| 1 |
+<div class="col-md-12"> |
|
| 2 |
+ <div class="page-header"> |
|
| 3 |
+ <h2><%= @scenario_import.parsed_data["name"] %> (exported <%= time_ago_in_words Time.parse(@scenario_import.parsed_data["exported_at"]) %> ago)</h2> |
|
| 4 |
+ </div> |
|
| 5 |
+ |
|
| 6 |
+ <% if @scenario_import.parsed_data["description"].present? %> |
|
| 7 |
+ <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote> |
|
| 8 |
+ <% end %> |
|
| 9 |
+ |
|
| 10 |
+ <p> |
|
| 11 |
+ This import contains <%= pluralize @scenario_import.parsed_data["agents"].length, "Agent" %>: |
|
| 12 |
+ </p> |
|
| 13 |
+ |
|
| 14 |
+ <ul class='agent-import-list'> |
|
| 15 |
+ <% @scenario_import.parsed_data["agents"].each do |agent_data| %> |
|
| 16 |
+ <li> |
|
| 17 |
+ <%= link_to agent_data['name'], '#', :class => 'options-toggle' %> |
|
| 18 |
+ <span class='text-muted'> |
|
| 19 |
+ (<%= agent_data["type"].split("::").pop.titleize %>)
|
|
| 20 |
+ </span> |
|
| 21 |
+ <pre class='options' style='display: none;'><%= Utils.pretty_jsonify agent_data["options"] || {} %></pre>
|
|
| 22 |
+ </li> |
|
| 23 |
+ <% end %> |
|
| 24 |
+ </ul> |
|
| 25 |
+ |
|
| 26 |
+ <script> |
|
| 27 |
+ $(function() {
|
|
| 28 |
+ $('.agent-import-list .options-toggle').on('click', function(e) {
|
|
| 29 |
+ e.preventDefault(); |
|
| 30 |
+ $(this).siblings('.options').fadeToggle();
|
|
| 31 |
+ }); |
|
| 32 |
+ }); |
|
| 33 |
+ </script> |
|
| 34 |
+ |
|
| 35 |
+ <% if @scenario_import.existing_scenario.present? %> |
|
| 36 |
+ <strong> |
|
| 37 |
+ This Scenario already exists on your Huginn. |
|
| 38 |
+ If you continue, the import will overwrite your existing <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario. |
|
| 39 |
+ </strong> |
|
| 40 |
+ <% end %> |
|
| 41 |
+ |
|
| 42 |
+ <div class="checkbox"> |
|
| 43 |
+ <%= f.label :do_import do %> |
|
| 44 |
+ <%= f.check_box :do_import %> I confirm that I want to import these Agents. |
|
| 45 |
+ <% end %> |
|
| 46 |
+ </div> |
|
| 47 |
+ |
|
| 48 |
+ <div class='form-actions'> |
|
| 49 |
+ <%= f.submit "Finish Import", :class => "btn btn-primary" %> |
|
| 50 |
+ </div> |
|
| 51 |
+</div> |
@@ -0,0 +1,34 @@ |
||
| 1 |
+<div class='container'> |
|
| 2 |
+ <div class='row'> |
|
| 3 |
+ <div class='col-md-12'> |
|
| 4 |
+ <% if @scenario_import.errors.any? %> |
|
| 5 |
+ <div class="row well"> |
|
| 6 |
+ <h2><%= pluralize(@scenario_import.errors.count, "error") %> prohibited this Scenario from being imported:</h2> |
|
| 7 |
+ <% @scenario_import.errors.full_messages.each do |msg| %> |
|
| 8 |
+ <p class='text-warning'><%= msg %></p> |
|
| 9 |
+ <% end %> |
|
| 10 |
+ </div> |
|
| 11 |
+ <% end %> |
|
| 12 |
+ |
|
| 13 |
+ <%= form_for @scenario_import, :multipart => true do |f| %> |
|
| 14 |
+ <%= f.hidden_field :data %> |
|
| 15 |
+ |
|
| 16 |
+ <div class="row"> |
|
| 17 |
+ <% if @scenario_import.step_one? %> |
|
| 18 |
+ <%= render 'step_one', :f => f %> |
|
| 19 |
+ <% elsif @scenario_import.step_two? %> |
|
| 20 |
+ <%= render 'step_two', :f => f %> |
|
| 21 |
+ <% end %> |
|
| 22 |
+ </div> |
|
| 23 |
+ <% end %> |
|
| 24 |
+ |
|
| 25 |
+ <hr /> |
|
| 26 |
+ |
|
| 27 |
+ <div class="row"> |
|
| 28 |
+ <div class="col-md-12"> |
|
| 29 |
+ <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %> |
|
| 30 |
+ </div> |
|
| 31 |
+ </div> |
|
| 32 |
+ </div> |
|
| 33 |
+ </div> |
|
| 34 |
+</div> |
@@ -7,9 +7,7 @@ |
||
| 7 | 7 |
</h2> |
| 8 | 8 |
</div> |
| 9 | 9 |
|
| 10 |
- <blockquote> |
|
| 11 |
- Scenarios are named groups of Agents. Scenarios allow you to organize your agents, and to export sets of Agents for sharing. |
|
| 12 |
- </blockquote> |
|
| 10 |
+ <blockquote>Scenarios are named groups of Agents. Scenarios allow you to organize your agents, and to export sets of Agents for sharing.</blockquote> |
|
| 13 | 11 |
|
| 14 | 12 |
<table class='table table-striped'> |
| 15 | 13 |
<tr> |
@@ -42,6 +40,7 @@ |
||
| 42 | 40 |
|
| 43 | 41 |
<div class="btn-group"> |
| 44 | 42 |
<%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %> |
| 43 |
+ <%= link_to '<span class="glyphicon glyphicon-plus"></span> Import Scenario'.html_safe, new_scenario_imports_path, class: "btn btn-default" %> |
|
| 45 | 44 |
</div> |
| 46 | 45 |
</div> |
| 47 | 46 |
</div> |
@@ -28,6 +28,10 @@ Huginn::Application.routes.draw do |
||
| 28 | 28 |
end |
| 29 | 29 |
|
| 30 | 30 |
resources :scenarios do |
| 31 |
+ collection do |
|
| 32 |
+ resource :scenario_imports, :only => [:new, :create] |
|
| 33 |
+ end |
|
| 34 |
+ |
|
| 31 | 35 |
member do |
| 32 | 36 |
get :share |
| 33 | 37 |
get :export |
@@ -0,0 +1,7 @@ |
||
| 1 |
+class AddIndicesToScenarios < ActiveRecord::Migration |
|
| 2 |
+ def change |
|
| 3 |
+ add_index :scenarios, [:user_id, :guid] |
|
| 4 |
+ add_index :scenario_memberships, :agent_id |
|
| 5 |
+ add_index :scenario_memberships, :scenario_id |
|
| 6 |
+ end |
|
| 7 |
+end |
@@ -11,7 +11,7 @@ |
||
| 11 | 11 |
# |
| 12 | 12 |
# It's strongly recommended that you check this file into your version control system. |
| 13 | 13 |
|
| 14 |
-ActiveRecord::Schema.define(version: 20140531232016) do |
|
| 14 |
+ActiveRecord::Schema.define(version: 20140602014917) do |
|
| 15 | 15 |
|
| 16 | 16 |
create_table "agent_logs", force: true do |t| |
| 17 | 17 |
t.integer "agent_id", null: false |
@@ -97,6 +97,9 @@ ActiveRecord::Schema.define(version: 20140531232016) do |
||
| 97 | 97 |
t.datetime "updated_at" |
| 98 | 98 |
end |
| 99 | 99 |
|
| 100 |
+ add_index "scenario_memberships", ["agent_id"], name: "index_scenario_memberships_on_agent_id", using: :btree |
|
| 101 |
+ add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree |
|
| 102 |
+ |
|
| 100 | 103 |
create_table "scenarios", force: true do |t| |
| 101 | 104 |
t.string "name", null: false |
| 102 | 105 |
t.integer "user_id", null: false |
@@ -108,6 +111,8 @@ ActiveRecord::Schema.define(version: 20140531232016) do |
||
| 108 | 111 |
t.string "source_url" |
| 109 | 112 |
end |
| 110 | 113 |
|
| 114 |
+ add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", using: :btree |
|
| 115 |
+ |
|
| 111 | 116 |
create_table "user_credentials", force: true do |t| |
| 112 | 117 |
t.integer "user_id", null: false |
| 113 | 118 |
t.string "credential_name", null: false |
@@ -0,0 +1,29 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe ScenarioImportsController do |
|
| 4 |
+ def valid_attributes(options = {})
|
|
| 5 |
+ { :name => "some_name" }.merge(options)
|
|
| 6 |
+ end |
|
| 7 |
+ |
|
| 8 |
+ before do |
|
| 9 |
+ sign_in users(:bob) |
|
| 10 |
+ end |
|
| 11 |
+ |
|
| 12 |
+ describe "GET new" do |
|
| 13 |
+ it "initializes a new ScenarioImport and renders new" do |
|
| 14 |
+ get :new |
|
| 15 |
+ assigns(:scenario_import).should be_a(ScenarioImport) |
|
| 16 |
+ response.should render_template(:new) |
|
| 17 |
+ end |
|
| 18 |
+ end |
|
| 19 |
+ |
|
| 20 |
+ describe "POST create" do |
|
| 21 |
+ it "initializes a ScenarioImport for current_user, passing in params" do |
|
| 22 |
+ post :create, :scenario_import => { :url => "bad url" }
|
|
| 23 |
+ assigns(:scenario_import).user.should == users(:bob) |
|
| 24 |
+ assigns(:scenario_import).url.should == "bad url" |
|
| 25 |
+ response.should render_template(:new) |
|
| 26 |
+ end |
|
| 27 |
+ end |
|
| 28 |
+end |
|
| 29 |
+ |
@@ -51,7 +51,8 @@ describe Agents::SlackAgent do |
||
| 51 | 51 |
username: @event.payload[:username] |
| 52 | 52 |
) |
| 53 | 53 |
end |
| 54 |
- expect(@checker.receive([@event])).to_not raise_error |
|
| 54 |
+ |
|
| 55 |
+ lambda { @checker.receive([@event]) }.should_not raise_error
|
|
| 55 | 56 |
end |
| 56 | 57 |
end |
| 57 | 58 |
|
@@ -0,0 +1,80 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe ScenarioImport do |
|
| 4 |
+ describe "initialization" do |
|
| 5 |
+ it "is initialized with an attributes hash" do |
|
| 6 |
+ ScenarioImport.new(:url => "http://google.com").url.should == "http://google.com" |
|
| 7 |
+ end |
|
| 8 |
+ end |
|
| 9 |
+ |
|
| 10 |
+ describe "validations" do |
|
| 11 |
+ subject { ScenarioImport.new }
|
|
| 12 |
+ let(:valid_json) { { :name => "some scenario", :guid => "someguid" }.to_json }
|
|
| 13 |
+ let(:invalid_json) { { :name => "some scenario missing a guid" }.to_json }
|
|
| 14 |
+ |
|
| 15 |
+ it "is not valid when none of file, url, or data are present" do |
|
| 16 |
+ subject.should_not be_valid |
|
| 17 |
+ subject.should have(1).error_on(:base) |
|
| 18 |
+ subject.errors[:base].should include("Please provide either a Scenario JSON File or a Public Scenario URL.")
|
|
| 19 |
+ end |
|
| 20 |
+ |
|
| 21 |
+ describe "data" do |
|
| 22 |
+ it "should be invalid with invalid data" do |
|
| 23 |
+ subject.data = invalid_json |
|
| 24 |
+ subject.should_not be_valid |
|
| 25 |
+ subject.should have(1).error_on(:base) |
|
| 26 |
+ |
|
| 27 |
+ subject.data = "foo" |
|
| 28 |
+ subject.should_not be_valid |
|
| 29 |
+ subject.should have(1).error_on(:base) |
|
| 30 |
+ |
|
| 31 |
+ # It also clears the data when invalid |
|
| 32 |
+ subject.data.should be_nil |
|
| 33 |
+ end |
|
| 34 |
+ |
|
| 35 |
+ it "should be valid with valid data" do |
|
| 36 |
+ subject.data = valid_json |
|
| 37 |
+ subject.should be_valid |
|
| 38 |
+ end |
|
| 39 |
+ end |
|
| 40 |
+ |
|
| 41 |
+ describe "url" do |
|
| 42 |
+ it "should be invalid with an unreasonable URL" do |
|
| 43 |
+ subject.url = "foo" |
|
| 44 |
+ subject.should_not be_valid |
|
| 45 |
+ subject.should have(1).error_on(:url) |
|
| 46 |
+ subject.errors[:url].should include("appears to be invalid")
|
|
| 47 |
+ end |
|
| 48 |
+ |
|
| 49 |
+ it "should be invalid when the referenced url doesn't contain a scenario" do |
|
| 50 |
+ stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_json) |
|
| 51 |
+ subject.url = "http://example.com/scenarios/1/export.json" |
|
| 52 |
+ subject.should_not be_valid |
|
| 53 |
+ subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
|
|
| 54 |
+ end |
|
| 55 |
+ |
|
| 56 |
+ it "should be valid when the url points to a valid scenario" do |
|
| 57 |
+ stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_json) |
|
| 58 |
+ subject.url = "http://example.com/scenarios/1/export.json" |
|
| 59 |
+ subject.should be_valid |
|
| 60 |
+ end |
|
| 61 |
+ end |
|
| 62 |
+ |
|
| 63 |
+ describe "file" do |
|
| 64 |
+ it "should be invalid when the uploaded file doesn't contain a scenario" do |
|
| 65 |
+ subject.file = StringIO.new("foo")
|
|
| 66 |
+ subject.should_not be_valid |
|
| 67 |
+ subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
|
|
| 68 |
+ |
|
| 69 |
+ subject.file = StringIO.new(invalid_json) |
|
| 70 |
+ subject.should_not be_valid |
|
| 71 |
+ subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
|
|
| 72 |
+ end |
|
| 73 |
+ |
|
| 74 |
+ it "should be valid with a valid uploaded scenario" do |
|
| 75 |
+ subject.file = StringIO.new(valid_json) |
|
| 76 |
+ subject.should be_valid |
|
| 77 |
+ end |
|
| 78 |
+ end |
|
| 79 |
+ end |
|
| 80 |
+end |